Run the agent in Docker
Protect PostgreSQL in the official postgres image with a sidecar agent container.
For PostgreSQL in the official postgres:NN image (Debian or Alpine). The agent runs as a sidecar container in your compose project: add a few volumes and one service, nothing on the host. For one-click restart and whole-database rewind, add one more small service: Let Rowsafe restart the container.
Linux Docker hosts only for production
Docker Desktop (macOS, Windows) runs in a VM whose disk and fsync behaviour Rowsafe doesn't guarantee. Use it to try Rowsafe only. Kubernetes isn't covered.
Other engines have their own images: MySQL and MariaDB, MongoDB, ClickHouse, Redis or Valkey, SQLite.
How it works
compose project
+-------------------------------+ +------------------------------------------+
| postgres (postgres:17, stock) | | rowsafe-agent (ghcr.io/rowsafe/agent: |
| | | pg17) |
| archive_command: | | |
| cp + fsync + rename --------+--+ | spool pusher: pgbackrest archive-push |
| | | | each file in order, delete after OK --+--> your bucket
| /var/lib/postgresql/data <--+--+-----+-- same volume, same path, read-only | (encrypted)
| /var/run/postgresql <--+--+-----+-- socket: SQL |
| /rowsafe-spool/<db>/ <--+--+-----+-- spool: WAL waiting to be pushed |
+-------------------------------+ +------------------------------------------+archive_commandcopies each WAL file into a shared spool volume with standard tools (cp,sync,mv). It never overwrites a different file.- The agent pushes spooled files to your bucket in order, and deletes each only once stored.
- Backups, Marks, Proof and Rewind copies run in the agent container, which reads the data volume read-only at the same path (unless you allow whole-database rewind).
- Only
archive_commandruns in the PostgreSQL container. Rowsafe never restarts it unless you let it and someone clicks Restart.
The spool must be on durable disk
Until the agent pushes a WAL file, the spool holds its only copy. Use a named volume on local disk, never tmpfs or a network filesystem that ignores fsync. Don't delete spooled files by hand.
Set up
You need an enrollment token (rowsafe hosts enroll-token), a bucket and its keys, and an encryption passphrase saved in your secret manager first (steps 2 and 3 of Adopt an existing database).
Put the settings in rowsafe-agent.env next to your compose file. chmod 600 it and never commit it:
ROWSAFE_ENROLL_TOKEN=rse_... # first start only; delete it afterwards
ROWSAFE_REPO_S3_ENDPOINT=<account-id>.eu.r2.cloudflarestorage.com
ROWSAFE_REPO_S3_BUCKET=app-rowsafe
ROWSAFE_REPO_S3_KEY=...
ROWSAFE_REPO_S3_KEY_SECRET=...
ROWSAFE_REPO_CIPHER_PASS=...
# ROWSAFE_PG_USER=app # if POSTGRES_USER is not postgresAdd the agent to your compose file. Your postgres service keeps everything else.
services:
postgres:
image: postgres:17
volumes:
- pgdata:/var/lib/postgresql/data
- pgsocket:/var/run/postgresql # added
- rowsafe-spool:/rowsafe-spool # added
rowsafe-agent:
image: ghcr.io/rowsafe/agent:pg17 # pg + your PostgreSQL major
hostname: db-1 # the host name Rowsafe shows
user: "999:999" # the postgres user of the Debian images
restart: unless-stopped
depends_on: [postgres]
env_file: rowsafe-agent.env
shm_size: 256m # Proof starts a scratch PostgreSQL here
volumes:
- pgdata:/var/lib/postgresql/data:ro # same volume, same path, read-only
- pgsocket:/var/run/postgresql
- rowsafe-spool:/rowsafe-spool
- rowsafe-state:/var/lib/rowsafe
volumes:
pgdata:
pgsocket:
rowsafe-spool:
rowsafe-state:The tag must match your PostgreSQL major (pg14 to pg18) and tracks the newest Rowsafe release. To pin one, use an exact tag like ghcr.io/rowsafe/agent:0.5.0-pg17 (releases).
Start it:
docker compose up -d
docker compose logs rowsafe-agent # "enrolled"
rowsafe hosts list # db-1- Adding the two mounts recreates the
postgrescontainer: a few seconds of downtime. Do it in a maintenance window. - With a bind mount, mount the same host directory at the same path in both services.
- Keep the
rowsafe-statevolume: it holds the host's identity and configuration. Lose it and you enroll again with a new token.
Adopt
In the dashboard: Databases, then Add database. Or with the CLI:
rowsafe adopt app --host db-1 # read-only plan
rowsafe apply app # config, spool directory, ALTER SYSTEM + reload
docker compose restart postgres # you, in a maintenance windowRowsafe notices the restart and proves WAL reaches the bucket. To check now, click Check now (or rowsafe verify app).
Settings go into postgresql.auto.conf in the data volume, so they survive container re-creation.
Back up an uploads volume
Back up your app's uploads volume with the database. Mount it into the agent under /rowsafe-files (read-only is enough), recreate the agent, and add /rowsafe-files/uploads in Rewind, then Files:
services:
rowsafe-agent:
volumes:
- uploads:/rowsafe-files/uploads:roRestored files land in /var/lib/rowsafe/files-restored/<id> unless the volume is mounted read-write and writable by the agent. See Back up and restore files.
Rewind in Docker
- Restore a copy, compare and bring back rows work as on a server (Restore a database). The copy lives in the
rowsafe-statevolume, which needs about 1.3 × the database size + 1 GB free. - Rewinding the whole database needs the container control service and a writable data volume (below). Without it, restore a copy and bring back rows, or restore by hand into a new volume.
Let Rowsafe restart the container
Optional. Without it, you restart PostgreSQL yourself and whole-database rewind isn't offered. Add the rowsafe-docker-control service to get:
- Restart PostgreSQL: restarts the container and waits until it's healthy and PostgreSQL answers.
- Rewind the whole database: stops the container, puts the data back to the moment you pick, starts it again. The old data is kept 7 days for Undo.
The control service holds the Docker socket, never the agent, and acts on one container only: your PostgreSQL service. Read the Threat model first.
The database's Settings page shows these lines with your service and volume names filled in.
Add the service:
services:
rowsafe-docker-control:
image: ghcr.io/rowsafe/docker-control:latest # or pin a release, like :0.5.0
restart: unless-stopped
depends_on: [postgres]
environment:
ROWSAFE_CONTROL_SERVICE: postgres # your PostgreSQL service's name
volumes:
- /var/run/docker.sock:/var/run/docker.sock
- rowsafe-control:/run/rowsafe-control
network_mode: none
read_only: true
cap_drop: [ALL]
security_opt: ["no-new-privileges:true"]Give the agent the control volume and, for whole-database rewind, write access to the data volume (remove :ro):
rowsafe-agent:
volumes:
- pgdata:/var/lib/postgresql/data # was :ro. PostgreSQL 18+: pgdata:/var/lib/postgresql
- rowsafe-control:/run/rowsafe-control
# ... the other volumes as before
volumes:
rowsafe-control:Keep :ro if you only want Restart.
Apply it. This recreates the agent; PostgreSQL keeps running.
docker compose up -d
docker compose logs rowsafe-docker-control # "controlling container" myapp-postgres-1To take it back, remove the service and the rowsafe-control volume.
How a rewind works in Docker
The data directory can't be renamed in Docker, so the agent moves what's inside it, within the same volume:
- Rowsafe saves a Mark; the agent checks the repository, free space and write access.
- The control service stops the container (clean shutdown).
- The data moves into
.rowsafe-rewind/<name>.before-rewind-<time>/. - The backup is restored into
.rowsafe-rewind/restore-<id>/and moved into place, keeping your configuration (postgresql.conf,pg_hba.conf, archive settings). - Changes replay up to your moment, in the agent container, with no network.
- The control service starts the container; the agent waits for connections.
If a step fails, the original data goes back and the container starts again (also after an agent crash, on its next start). Owner and permissions are kept. .rowsafe-rewind stops the entrypoint from creating a new cluster meanwhile, and backups skip it.
It needs free space in the data volume for a second copy (size plus 10%) until you click Delete the old data now or it expires.
Threat model
Whoever can reach the Docker socket controls the host (a privileged container can mount /). Mounting it :ro changes nothing. That's why the agent never gets it, and why this is opt-in.
What the control service allows. A Unix socket on the rowsafe-control volume, mounted only by the agent. Requests carry an action and a log ID (plus, for an agent update, the version and signed image list); anything else is refused. Actions: inspect, stop, start, restart. The target is fixed by its own config: the compose service in ROWSAFE_CONTROL_SERVICE in its own project (or one container in ROWSAFE_CONTROL_CONTAINER), resolved at start and its labels rechecked before every action. Never itself; a service scaled to two containers is refused. Docker API calls: list by label, inspect, stop, start, restart. No exec, attach, logs, networks or volumes.
Agent updates (only with ROWSAFE_CONTROL_ALLOW_AGENT_UPDATE=1). Two more actions, update_agent and agent_ready, and the Docker calls they need: inspect an image, pull ghcr.io/rowsafe/agent (hard-coded) at a digest from a list signed with Rowsafe's release key, create, rename and remove agent containers. Only on the agent's compose service (ROWSAFE_CONTROL_AGENT_SERVICE, default rowsafe-agent) and only a container already running ghcr.io/rowsafe/agent. The control plane only relays the signed list, so a compromised control plane can withhold an update, not choose the image. At most four an hour, one at a time.
Who may ask. Only uid 999 or 70 (the postgres user of Debian and Alpine images; change with ROWSAFE_CONTROL_ALLOW_UIDS, e.g. 101 for the ClickHouse agent image), checked by kernel peer credentials. Requests must be small and arrive within 10 seconds.
Limits and log. At most one restart a minute and 20 stops and restarts an hour. Starting is never limited, so a rollback can always start PostgreSQL. Every request is logged (uid, action, container, outcome): docker compose logs rowsafe-docker-control.
Who triggers it. Only an owner or admin: in the dashboard, with rowsafe restart and a read-write API key, or through their AI agent (acting as them; a member's agent is refused). All in the audit log.
If the agent container is compromised, the attacker can stop or restart PostgreSQL (rate-limited), less than the agent's database access already allows. Nothing else on the host.
If the control service is compromised, the attacker is root on the host. It's built to make that hard: one static Go binary (about 2,300 lines, half for agent updates), an empty image, no network (network_mode: none), no capabilities, read-only filesystem, no-new-privileges. Signed with cosign, with SLSA provenance (Verifying releases). It runs as root in its container only to open the socket.
Why not a Docker socket proxy? Generic proxies like tecnativa/docker-socket-proxy filter by API section, not container: allowing restarts would allow any container to be stopped, created or removed.
If that trade isn't right for you, skip the service: restart the container yourself and restore copies instead of rewinding in place.
Upgrade
The agent never updates itself in Docker. Pull the newest image and recreate only the agent, ideally between backups. PostgreSQL keeps running:
docker compose pull rowsafe-agent && docker compose up -d rowsafe-agentName the service: a plain pull and up -d can also upgrade and restart PostgreSQL. With an exact tag (0.5.0-pg17), change the version first. The Agent updates card on the server's page says when one is out.
Update the agent from the dashboard
Optional, with the container control service. Add one line and pull its newest image:
rowsafe-docker-control:
image: ghcr.io/rowsafe/docker-control:latest
environment:
ROWSAFE_CONTROL_SERVICE: postgres
ROWSAFE_CONTROL_ALLOW_AGENT_UPDATE: "1" # added
# ROWSAFE_CONTROL_AGENT_SERVICE: rowsafe-agent # if your agent's service has another namedocker compose pull rowsafe-docker-control && docker compose up -d rowsafe-docker-controlThen the server's page shows Update now. When an owner or admin clicks and confirms, the control service:
- checks the release's image list against Rowsafe's signature (key built in) and picks your image's digest (
pg17,pg17-alpine,clickhouse26.8...); - pulls
ghcr.io/rowsafe/agentat that digest; - creates a new agent container with the old one's exact settings, only the image changed;
- swaps them and waits up to five minutes for the new agent to be healthy and report in;
- removes the old container. Volumes are never removed.
If the new one doesn't come up, the old one starts again and the dashboard says why. PostgreSQL keeps running; WAL waits in the spool for those seconds. An interrupted backup or Proof run reports as failed and runs again on schedule. Logged in the audit log and docker compose logs rowsafe-docker-control.
Limits:
- It only replaces exactly one container of the agent's service, running
ghcr.io/rowsafe/agent(not your own build). - The new container runs by digest. If your compose file pins an older exact tag, a later
docker compose up -d --force-recreategoes back to it, so use the floatingpg<major>tag (clickhouse26.8for ClickHouse). - Works from the release after 0.5.0, for agents and control services from then on.
Rowsafe doesn't do PostgreSQL major upgrades in Docker. After one, switch to the new major's agent tag and adopt the database again (a new history).
Watch the spool
The agent reports the spool with every heartbeat. In Docker, the WAL status in rowsafe show app describes your bucket: "last archived" is the last file pushed, and failed pushes count as archiving failures.
- If the oldest spooled file waits over 5 minutes (
ROWSAFE_SPOOL_STALL_AFTER), you get thewal_archiving_failingalert and the database stops counting as protected. - The health check fails too, so
docker psshowsunhealthy. - The API (
GET /v1/databases/{ref}) shows the spool's file count, size and last push error.
Troubleshooting
The agent checks its environment at startup and names the fix:
| Message | Fix |
|---|---|
refusing to run as root | Set user: "999:999" (or "70:70" for Alpine images). |
PostgreSQL's socket directory /var/run/postgresql is owned by uid 70, but the agent runs as uid 999 | Alpine image: use the agent's -alpine tag and user: "70:70". |
the agent runs as uid N, which has no user in this image | Use the agent tag that matches your postgres image flavour. |
the WAL spool /rowsafe-spool is missing | Mount the spool volume in both services. |
the WAL spool ... is owned by uid 0 | Another container created the volume first. Recreate it while empty, or chown 999:999 it. |
PostgreSQL's data directory ... is not visible in the agent container | Mount the data volume at the same path in both services (for 18+, at /var/lib/postgresql). |
... belongs to another cluster (system identifier ...) | The agent sees a different volume at that path. |
this agent image has no PostgreSQL N server binaries | Use the -pgN tag for your server's major version. |
Rowsafe can't restart PostgreSQL running in Docker yet | Add the container control service (Let Rowsafe restart the container), or restart the container yourself. |
found no container for the compose service "postgres" | Set ROWSAFE_CONTROL_SERVICE to your PostgreSQL service's name, then docker compose up -d. |
the PostgreSQL data volume is mounted read-only in the agent container | Remove :ro from the agent's data volume to allow Rewind the whole database. |
uid N may not use this service | The agent runs as another user: set ROWSAFE_CONTROL_ALLOW_UIDS on the control service. |
updating the agent's container isn't allowed | Set ROWSAFE_CONTROL_ALLOW_AGENT_UPDATE: "1" on the control service (Update the agent from the dashboard), or update it yourself. |
the agent container runs "...", not an image of ghcr.io/rowsafe/agent | Rowsafe only replaces its own published image: update a custom image yourself. |
found no container for the agent's compose service | Set ROWSAFE_CONTROL_AGENT_SERVICE to your agent's service name. |
If WAL piles up in the spool, docker compose logs rowsafe-agent shows the error. It drains by itself once fixed.